WebView와 Native의 책임 경계

WebView와 Native의 책임 경계

한눈에 보기

WebView와 Native를 나누는 기준은 언어나 화면 모양이 아니라 누가 원본 상태와 최종 부수효과를 소유하는가이다. 웹은 웹 도메인 화면과 라우팅을, Native는 OS 권한·보안 저장소·파일·푸시·앱 수명주기를 소유하게 한다. Bridge는 양쪽 상태를 동기화하는 만능 통로가 아니라, Web이 허용된 Native capability를 요청하고 명시적인 결과를 받는 좁은 프로토콜이어야 한다.

목차

WebView를 넣은 순간 생기는 두 개의 애플리케이션

WebView 기반 앱은 겉으로는 화면 하나지만 내부에는 서로 다른 실행 환경이 있다.

두 환경은 배포 주기, navigation, memory, storage, error model이 다르다. Flutter 상태가 바뀌었다고 Web의 React state가 자동으로 바뀌지 않고, SPA route가 이동했다고 Native Navigator stack이 줄어들지 않는다. 앱이 background로 가면 Web timer가 지연될 수 있고, page reload가 발생하면 JavaScript memory는 사라진다.

flowchart TB
    subgraph Native["Native shell"]
        N1[App lifecycle]
        N2[OS permissions]
        N3[Secure storage]
        N4[Native navigation]
    end

    subgraph Bridge["Versioned bridge"]
        B1[Request validation]
        B2[Capability dispatch]
        B3[Result and error mapping]
    end

    subgraph Web["Web application"]
        W1[Web domain state]
        W2[SPA routing]
        W3[Forms and presentation]
        W4[Web release]
    end

    Web --> Bridge --> Native
    Native --> Bridge --> Web

문제는 WebView를 썼다는 사실보다 두 애플리케이션 사이의 책임이 문서화되지 않았을 때 생긴다.

예를 들어 로그인 상태를 Native도 갖고 Web의 localStorage도 갖게 하면 어느 쪽이 원본인지 불분명해진다. 카메라 권한을 Web JavaScript가 판단하면서 Native도 별도의 permission state를 cache하면 설정 앱에서 권한이 바뀐 뒤 두 값이 달라질 수 있다.

이 글의 질문

“이 코드를 어느 폴더에 둘까?”가 아니라 “이 기능의 최종 결정을 내리고 부수효과를 실행하며 복구를 책임지는 쪽은 어디인가?”를 묻는다.

예제는 특정 프로젝트의 실제 코드를 가져오지 않고, 주문 내역 Web UI를 Flutter shell 안에서 실행한다는 가상의 구조로 재구성했다.

책임을 코드 위치가 아니라 소유권으로 나누기

Native와 Web을 나누는 기준을 “Native 기능이면 Native, 화면이면 Web”으로만 두면 애매한 사례가 많다. 파일 업로드 화면은 Web UI지만 picker는 OS 기능이고, 로그인 화면은 Web이어도 refresh token은 Native Keychain이 소유할 수 있다.

기능마다 다음 세 가지를 정하면 경계가 선명해진다.

  1. 결정권: 실행 여부와 정책을 누가 판단하는가
  2. 원본 상태: 충돌할 때 어느 값을 신뢰하는가
  3. 부수효과: 권한 요청, 파일 생성, token 저장을 누가 확정하는가
기능: 사진 첨부
결정권: Web은 사용자의 첨부 의도를 결정
원본 상태: 선택 완료 전에는 Native picker, 완료 후에는 서버 attachment ID
부수효과: Native가 photo picker 실행, Web이 업로드 요청과 form 연결
기능: 로그아웃
결정권: 인증 도메인 정책
원본 상태: Native session repository
부수효과: Native가 credential 삭제, Web은 memory state와 화면 초기화

양쪽이 같은 책임을 “도와준다”는 표현은 피한다. 실패했을 때 최종 복구를 누가 하는지까지 정해야 실제 소유자가 드러난다.

기능별 단일 소유자 정하기

기본 책임표를 먼저 만들고 예외가 있을 때 근거를 기록한다.

기능 기본 소유자 상대 영역의 역할
웹 도메인 화면·form validation Web Native는 container 제공
SPA route와 page state Web Native에 route summary 통지
OS 권한 요청 Native Web은 capability 요청과 결과 표시
camera·photo picker Native Web은 선택 의도와 결과 handle 소비
push token·notification tap Native Web에 정제된 navigation command 전달
refresh token·Keychain Native Web은 짧은 session capability 사용
Web API 호출 Web 또는 Native 중 하나 중복 client 금지
앱 deep link Native 진입, Web route 해석 명시적 route mapping
Web bundle version Native manifest와 Web handshake로 호환성 확인
analytics event 소유자가 생성 공통 correlation ID 사용

이 표는 절대 규칙이 아니다. offline bundle을 Native가 관리하고 Web의 API를 Native proxy가 호출하는 앱도 있다. 중요한 것은 한 기능에 두 명의 최종 소유자를 만들지 않는 것이다.

경계가 잘못됐다는 신호

같은 boolean을 양쪽에서 저장하거나, Web 오류를 고치기 위해 앱 배포가 필요하고 Native 오류를 고치기 위해 웹 배포가 필요한 상황이 반복되면 책임이 순환하고 있을 가능성이 크다.

Web은 화면과 웹 도메인 흐름을 소유한다

Web으로 선택한 업무 흐름은 가능한 한 Web 안에서 완결한다.

예를 들어 주문 취소 확인 modal을 Native에 만들고 실제 취소 요청은 Web이 보내면 Web state와 Native modal state를 함께 맞춰야 한다. OS UI가 필요한 것이 아니라면 Web이 modal부터 요청 결과까지 소유하는 편이 단순하다.

type CancelOrderState =
  | { type: "idle" }
  | { type: "confirming"; orderId: string }
  | { type: "submitting"; orderId: string }
  | { type: "failed"; orderId: string; message: string };

async function confirmCancellation(orderId: string) {
  setState({ type: "submitting", orderId });

  try {
    await orderApi.cancel(orderId);
    router.replace("/orders");
  } catch {
    setState({
      type: "failed",
      orderId,
      message: "주문을 취소하지 못했습니다.",
    });
  }
}

Web이 Native에 “취소 modal을 띄워 달라”고 요청할 이유가 없다. 반대로 system share sheet, biometric prompt, photo picker처럼 OS가 제공하는 UX는 Native에 요청한다.

Native는 OS capability와 보안 경계를 소유한다

Native가 소유해야 자연스러운 기능에는 공통점이 있다.

Web은 capability를 요청하지만 권한의 현재 상태를 임의로 확정하지 않는다.

const result = await nativeBridge.request({
  version: 1,
  type: "PHOTO_PICKER_OPEN",
  requestId: crypto.randomUUID(),
  payload: {
    selectionLimit: 3,
    mediaTypes: ["image"],
  },
});

if (result.status === "cancelled") {
  return;
}

if (result.status === "denied") {
  showPermissionGuide(result.canOpenSettings);
  return;
}

attachFiles(result.files);

Web은 permissionGranted = true를 오래 cache하지 않는다. 사용자는 앱 밖의 Settings에서 권한을 바꿀 수 있다. Native가 실행 시점에 OS의 현재 상태를 확인하고 granted, denied, limited, cancelled, unavailable처럼 의미 있는 결과를 반환한다.

Bridge는 공유 서비스가 아니라 제한된 포트다

나쁜 bridge는 Native 객체를 JavaScript에 넓게 노출한다.

// 피해야 할 형태
window.Native.execute("FileManager", "read", arbitraryPath);
window.Native.setValue(arbitraryKey, arbitraryValue);
window.Native.open(arbitraryUrl);

이 API는 호출자가 파일 경로, 저장 key, URL scheme을 자유롭게 선택한다. XSS나 신뢰하지 않는 iframe이 bridge에 도달했을 때 Native 앱 권한으로 기능을 실행할 가능성이 커진다.

좋은 bridge는 허용한 업무 capability만 노출한다.

type NativeRequest =
  | {
      version: 1;
      type: "PHOTO_PICKER_OPEN";
      requestId: string;
      payload: {
        selectionLimit: number;
        mediaTypes: Array<"image">;
      };
    }
  | {
      version: 1;
      type: "SHARE_ORDER_LINK";
      requestId: string;
      payload: {
        orderPublicId: string;
      };
    }
  | {
      version: 1;
      type: "APP_SETTINGS_OPEN";
      requestId: string;
      payload: {};
    };

Web은 임의 URL을 share sheet로 넘기지 않고 공개 주문 ID만 보낸다. Native가 허용된 base URL로 최종 링크를 만든다.

Future<ShareResult> shareOrderLink(
  ShareOrderLink request,
) async {
  if (!orderIdPattern.hasMatch(request.orderPublicId)) {
    return const ShareResult.invalidArgument();
  }

  final uri = Uri.https(
    'app.example.invalid',
    '/orders/${request.orderPublicId}',
  );

  return shareSheet.present(uri);
}

bridge에 새 capability를 추가하는 것은 public API를 추가하는 것처럼 review한다.

envelope과 version 호환성은 WebView 브릿지를 버전 있는 프로토콜로 만들기에서 상세히 다룬다.

같은 상태를 양쪽에서 수정하지 않기

Hybrid 앱에서 가장 어려운 버그는 값 자체보다 소유권 충돌에서 나온다.

sequenceDiagram
    participant Web
    participant Native
    participant Server

    Web->>Web: localStorage session 갱신
    Native->>Server: refresh token으로 갱신
    Server-->>Native: 새 access token
    Web->>Server: 이전 token으로 요청
    Server-->>Web: 401
    Web->>Web: 로그아웃 처리
    Native->>Web: 새 session 전달
    Note over Web,Native: 로그인/로그아웃 상태가 진동

인증 credential의 원본을 Native로 정했다면 Web은 memory 안의 짧은 access session만 사용하고 refresh와 영속화를 Native에 맡긴다. Web이 401을 받았을 때 자체 refresh를 시작하지 않고 bridge에 session 재확인을 요청한다.

상태를 다음 세 종류로 구분하면 도움이 된다.

상태 종류 처리
source of truth Keychain refresh credential 소유자만 수정
projection Web에 전달한 access session 만료 가능, 재요청
notification SESSION_CHANGED event 값이 아니라 다시 읽으라는 신호

notification에 전체 비밀값을 계속 broadcast하지 않는다. “상태가 변했다”는 event를 받고 Web이 필요한 projection을 명시적으로 요청하게 할 수 있다.

네트워크 요청의 소유자를 일관되게 정하기

Web fetch와 Native HTTP client가 같은 API를 나눠 호출하면 cookie, cache, retry, 인증 refresh, tracing이 두 벌이 된다. endpoint 단위로 임의 분배하지 말고 경계 원칙을 정한다.

Web이 API를 직접 호출하는 방식

웹 브라우저와 WebView에서 같은 앱을 재사용하기 쉽다. Web 도메인 요청은 Web이 소유하고, Native는 session bootstrap과 device capability만 제공한다.

Web UI → Web API client → Server
          ↑
    Native session capability

Native가 API proxy가 되는 방식

certificate pinning, 통합 offline queue, Native SDK가 필수라면 Native가 호출하고 Web은 typed bridge request를 보낼 수 있다.

Web UI → Bridge → Native API client → Server

이 방식에서는 bridge가 사실상 API gateway가 되므로 pagination, cancellation, large response, error schema 비용이 커진다. 모든 서버 DTO를 bridge로 복제할 정도라면 WebView 구조가 여전히 이득인지 다시 평가한다.

혼합이 가능한 경우

일반 업무 API는 Web, push token 등록이나 background upload는 Native처럼 capability 경계로 나눌 수 있다. 다만 같은 resource를 양쪽 cache가 독립적으로 수정하지 않게 invalidation 규칙을 둔다.

Native Navigator와 Web history는 서로 다른 stack이다.

Native stack
Home → HybridScreen → NativePhotoPreview

Web history inside HybridScreen
/orders → /orders/42 → /orders/42/edit

뒤로가기의 우선순위와 deep link mapping을 책임표에 포함한다.

Web이 Native 화면을 열 때 “화면 class 이름”을 요청하지 않는다.

{
  "version": 1,
  "type": "ORDER_ATTACHMENT_PREVIEW",
  "requestId": "req-demo-42",
  "payload": {
    "attachmentHandle": "attachment-demo-7"
  }
}

Native는 현재 app state와 handle 유효성을 확인한 뒤 적절한 화면을 선택한다. 뒤로가기 state machine은 WebView 뒤로가기와 Native 내비게이션 연결에서 이어서 정리한다.

파일과 카메라는 handle 중심으로 전달하기

Web에 sandbox 절대 경로를 넘기면 경로 수명과 권한이 Web 계약에 새어 나온다.

{
  "path": "/private/var/mobile/Containers/Data/temporary/photo.jpg"
}

대신 제한된 수명의 opaque handle과 필요한 metadata를 전달한다.

{
  "handle": "upload-demo-8f1",
  "mediaType": "image/jpeg",
  "size": 284102,
  "expiresAtEpochMs": 1760000000000
}

Web은 이 handle로 허용된 후속 작업만 요청한다.

await nativeBridge.request({
  version: 1,
  type: "ATTACHMENT_UPLOAD",
  requestId: crypto.randomUUID(),
  payload: {
    handle: selectedFile.handle,
    uploadPurpose: "order-evidence",
  },
});

Native는 handle을 자기 registry에서 찾고 다음을 검증한다.

작업 완료, 취소, WebView 폐기 때 temporary file을 정리한다. 큰 byte array를 JSON이나 JavaScript string으로 왕복시키는 것도 피한다.

수명주기가 끊겨도 복구할 수 있게 만들기

Web page는 reload될 수 있고 Native 화면은 background로 갈 수 있다. photo picker를 연 사이 WebView가 재생성될 수도 있다.

stateDiagram-v2
    [*] --> Requested
    Requested --> NativeUI: request accepted
    NativeUI --> Completed: user selected
    NativeUI --> Cancelled: user cancelled
    NativeUI --> Suspended: WebView destroyed
    Suspended --> Deliverable: same session restored
    Suspended --> Discarded: session expired
    Completed --> [*]
    Cancelled --> [*]
    Deliverable --> [*]
    Discarded --> [*]

requestId와 WebView sessionId를 구분한다.

이전 page가 시작한 결과를 새 page에 무조건 전달하면 사용자가 다른 계정이나 다른 form에 있을 수 있다. 복구 가능한 작업만 명시적으로 reattach하고, 나머지는 취소 처리한다.

Native에서 handler를 등록했으면 WebView dispose 시 해제한다. Web에서도 pending Promise를 timeout 또는 BRIDGE_DISCONNECTED로 종료한다. 아무 응답 없이 남겨 두면 loading UI가 영구히 지속된다.

웹과 앱의 배포 시차를 견디기

원격 Web을 로드한다면 Web은 오늘 배포되고 Native 앱은 사용자의 기기에 몇 달 전 버전으로 남을 수 있다. 반대로 offline bundle은 새 앱에 들어 있지만 서버 feature flag는 구버전일 수 있다.

초기 handshake에서 capability를 교환한다.

{
  "bridgeProtocol": {
    "min": 1,
    "max": 2
  },
  "nativeAppVersion": "5.4.0",
  "capabilities": [
    "photo-picker.v2",
    "share-order-link.v1",
    "secure-session.v1"
  ]
}

Web은 앱 버전 문자열을 비교해 기능을 추측하기보다 capability 존재를 확인한다.

if (bridgeCapabilities.has("photo-picker.v2")) {
  showNativePhotoPickerButton();
} else {
  showWebFileInputFallback();
}

필수 protocol 범위가 겹치지 않으면 어설프게 실행하지 않고 업데이트 안내나 호환 가능한 fallback 화면을 보여 준다. 알 수 없는 optional field는 무시하고, 필수 의미가 바뀌는 변경은 새 capability version으로 분리한다.

신뢰하지 않는 페이지에서 Bridge를 제거하기

WebView가 bridge를 가진 채 외부 링크로 이동하면 외부 페이지도 Native capability에 닿을 수 있다. Android 공식 보안 안내는 JavaScript interface가 모든 frame에 노출될 수 있고 호출 frame의 origin 검증이 어려운 위험을 지적한다. 신뢰하는 콘텐츠만 로드하고 navigation을 제한하는 것이 기본이다.

허용 정책은 정확한 origin 단위로 둔다.

final allowedOrigins = <String>{
  'https://app.example.invalid',
};

NavigationDecision decideNavigation(Uri uri) {
  final origin = '${uri.scheme}://${uri.host}'
      '${uri.hasPort ? ':${uri.port}' : ''}';

  if (allowedOrigins.contains(origin)) {
    return NavigationDecision.navigate;
  }

  externalBrowser.open(uri);
  return NavigationDecision.prevent;
}

실제 비교에서는 URL parser를 사용하고 scheme, normalized host, port를 모두 포함한다. host.endsWith('example.com') 같은 검사는 evil-example.com을 허용할 수 있다.

iOS의 WKScriptMessage에는 message body뿐 아니라 frameInfo, web view, content world 정보가 있다. top frame과 현재 navigation의 신뢰 상태를 확인한다. WKContentWorld는 app script와 page script의 JavaScript namespace 충돌을 줄여 주지만 DOM은 공유되므로 content world만으로 신뢰 문제가 모두 해결되지는 않는다.

외부 콘텐츠를 반드시 보여 줘야 한다면 다음 중 하나를 택한다.

XSS가 발생해도 bridge가 camera, file, token을 넓게 제공하지 않도록 capability 자체도 최소화한다. origin 검사는 입력 검증을 대체하지 않는다.

테스트와 관측 경계도 함께 나누기

책임 경계는 테스트 소유권에도 반영한다.

테스트 주요 검증
Web unit test UI state, request 생성, bridge error mapping
Bridge contract test version, type, payload, request-response 상관관계
Native unit test permission·file·session capability 구현
WebView integration test 실제 serialization, navigation, lifecycle
보안 test 외부 origin·iframe·잘못된 payload 거절
실제 기기 test OS picker, permission 변경, background 복귀

fake bridge는 성공만 반환하지 않고 취소·권한 거절·timeout·연결 끊김을 표현한다.

class FakeNativeBridge implements NativeBridge {
  constructor(
    private readonly handler: (
      request: NativeRequest,
    ) => Promise<NativeResponse>,
  ) {}

  requests: NativeRequest[] = [];

  async request(
    request: NativeRequest,
  ): Promise<NativeResponse> {
    this.requests.push(request);
    return this.handler(request);
  }
}

운영 로그는 양쪽의 event를 requestId로 연결하되 payload 원문은 남기지 않는다.

web_bridge_request type=PHOTO_PICKER_OPEN request_id=req-42
native_capability_result type=PHOTO_PICKER_OPEN result=cancelled duration_ms=820
web_bridge_settled type=PHOTO_PICKER_OPEN result=cancelled

Web error tracker와 Native crash report에 같은 correlation ID를 넣으면 어느 경계에서 사라졌는지 찾기 쉽다. token, file path, 사용자 입력은 로그에서 제외한다.

책임 경계 체크리스트

소유권

Bridge

배포와 보안

검증

마무리

WebView와 Native의 책임을 나누는 목적은 코드 폴더를 깔끔하게 만드는 데 있지 않다. 두 실행 환경이 서로 다른 lifecycle과 배포 주기를 가져도 하나의 기능이 예측 가능하게 동작하도록 만드는 데 있다.

Web은 선택한 웹 도메인의 화면, validation, SPA navigation을 소유한다. Native는 OS permission, secure storage, file, push처럼 플랫폼 권한이 필요한 capability를 소유한다. 인증이나 navigation처럼 양쪽에 걸친 기능도 원본 상태와 최종 부수효과의 소유자는 하나만 둔다.

Bridge는 이 경계를 없애는 도구가 아니다. 오히려 경계를 코드로 드러내는 protocol이다. 임의 명령을 실행하는 범용 객체 대신 버전이 있는 작은 capability를 제공하고, 입력·origin·frame·lifecycle을 검증한다. 그러면 Web과 Native는 서로의 내부 구현을 복제하지 않고도 필요한 결과만 교환할 수 있다.

관련 노트

참고 자료